Skip to content

[wrangler] Allow containers to be attached to Durable Objects via exports - #15026

Open
petebacondarwin wants to merge 7 commits into
mainfrom
devx-2628-containers-via-exports
Open

[wrangler] Allow containers to be attached to Durable Objects via exports#15026
petebacondarwin wants to merge 7 commits into
mainfrom
devx-2628-containers-via-exports

Conversation

@petebacondarwin

@petebacondarwin petebacondarwin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Fixes DEVX-2628.

Containers can now be attached to a Durable Object from the export side, using a new container field on a durable-object export that names an entry in the containers array:

{
	"name": "my-worker",
	"main": "worker.js",
	"compatibility_date": "2026-07-01",
	"containers": [
		{ "name": "my-container", "image": "./Dockerfile", "max_instances": 1 }
	],
	"exports": {
		"MyContainerDO": {
			"type": "durable-object",
			"storage": "sqlite",
			"container": "my-container"
		}
	}
}

As a result containers[].class_name is now optional — a container referenced this way only needs a name. This decouples container configuration from the Durable Object class, which is a prerequisite for configuring containers as standalone resources.

The existing containers[].class_name direction keeps working, and either direction may be used, but a Durable Object and its container must reference each other consistently when both are set.

How the link is resolved

Wrangler sends both directions in the upload metadata exactly as configured (containers[].name and exports[Class].container) and the API does its own resolution. Wrangler only pre-resolves the class name locally where it genuinely needs it — local dev image tagging and Cloudchamber app creation — via a single shared helper (resolveContainerClassName in @cloudflare/workers-utils) used by wrangler, the Vite plugin, and unstable_getMiniflareWorkerOptions.

New validation

container is only valid on live durable-object exports (created and expecting-transfer) and requires storage: "sqlite". Cross-field validation now reports:

  1. a container reference naming a container that isn't defined
  2. two Durable Object exports claiming the same container
  3. the two directions disagreeing with each other
  4. a container that ends up linked to no Durable Object
  5. class_name pointing at a class with no live durable-object export (only when the exports flow is in use — the legacy migrations flow keeps its silent-ignore behaviour)
  6. two containers sharing a name (behaviour change: previously the duplicate was silently tolerated)

Also included

  • @cloudflare/config: container added to exports.durableObject() for the experimental cloudflare.config.ts format.
  • fixtures/container-app now uses the new shape (wrangler.registry.jsonc retains the class_name + migrations equivalent for comparison).
  • Fixed a latent bug in validateContainerApp where the name type check tested the whole containers array rather than each entry, so it never fired.
  • Hardened partitionExports against malformed/unknown export types, which previously threw rather than letting validation report them.

  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: this is the wrangler half of a feature that is not yet announced; the containers/Durable Objects config docs will be updated as one piece when the feature ships. Config reference docs are generated from the JSON schema, which is updated here.

Test coverage added:

  • packages/workers-utils/tests/config/containers.test.ts — unit tests for the shared resolver.
  • packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts — all six validation rules, plus container shape and legacy-kv rejection.
  • packages/wrangler/src/__tests__/containers/{config,deploy,schema}.test.ts and create-worker-upload-form/metadata.test.ts — resolution, deploy, and upload-metadata coverage.
  • packages/wrangler/src/__tests__/dev.test.ts and packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts — local dev paths.
  • packages/config/src/__tests__/{convert,schema}.test.ts.
  • packages/wrangler/e2e/durable-objects-exports.test.ts — a live wrangler deploy e2e that asserts ctx.container is present on the deployed Durable Object (so it covers the API's half of the resolution end to end), plus a case that moves the link to class_name and re-deploys to confirm the two directions are interchangeable. Skipped when Docker is unavailable.

A picture of a cute animal (not mandatory, but encouraged)

a shipping container, but make it a cat

Note

This is a contribution from an AI agent: OpenCode, claude-opus-5.


Open in Devin Review

…ports`

A container can now be linked to its Durable Object from the export side,
using a new `container` field that names an entry in the `containers`
array:

    "containers": [{ "name": "my-container", "image": "./Dockerfile" }],
    "exports": {
      "MyContainerDO": {
        "type": "durable-object",
        "storage": "sqlite",
        "container": "my-container"
      }
    }

As a result `containers[].class_name` is now optional — a container that
is referenced this way only needs a `name`. This decouples container
configuration from the Durable Object class, which is a prerequisite for
configuring containers as standalone resources.

The link is resolved by a shared helper in `@cloudflare/workers-utils`,
used by wrangler's container normalization, the Vite plugin's dev/preview
container builds, and `unstable_getMiniflareWorkerOptions`. Upload
metadata sends both directions as configured (`containers[].name` and
`exports[].container`) so that the API can do its own resolution;
wrangler only pre-resolves for local dev and Cloudchamber app creation.

Cross-field validation now reports dangling `container` references, two
exports claiming the same container, the two directions disagreeing, a
container linked to no Durable Object, `class_name` with no live export,
and duplicate container names.
…exports`

Deploys a Worker whose container is linked only from the Durable Object's
`exports` entry, then asserts `ctx.container` is present on the deployed
Durable Object. That is only true when the API resolved the link from the
export side, so it covers the server half of the feature end to end.

A second case moves the same link back to `containers[].class_name` and
re-deploys, verifying the container stays attached and that the two
directions really are interchangeable.

The image is pushed once up front under a known tag so that teardown can
delete it deterministically, and so neither deploy has to build it. The
block is skipped when Docker is unavailable.
@changeset-bot

changeset-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 676980f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@cloudflare/config Minor
wrangler Minor
@cloudflare/vite-plugin Minor
@cloudflare/build-output-utils Patch
@cloudflare/vitest-pool-workers Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-project-automation github-project-automation Bot moved this to Untriaged in workers-sdk Aug 4, 2026
@workers-devprod
workers-devprod requested review from a team and emily-shen and removed request for a team August 4, 2026 21:24
@workers-devprod

workers-devprod commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/cloudchamber
  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/config-export-container-field.md: [@cloudflare/wrangler]
  • .changeset/containers-attached-via-exports.md: [@cloudflare/wrangler]
  • fixtures/container-app/wrangler.jsonc: [@cloudflare/wrangler]
  • packages/config/src/tests/convert.test.ts: [@cloudflare/wrangler]
  • packages/config/src/tests/schema.test.ts: [@cloudflare/wrangler]
  • packages/config/src/convert.ts: [@cloudflare/wrangler]
  • packages/config/src/exports.ts: [@cloudflare/wrangler]
  • packages/config/src/schema.ts: [@cloudflare/wrangler]
  • packages/deploy-helpers/src/deploy/helpers/create-worker-upload-form.ts: [@cloudflare/wrangler]
  • packages/vite-plugin-cloudflare/src/tests/containers.spec.ts: [@cloudflare/wrangler]
  • packages/vite-plugin-cloudflare/src/containers.ts: [@cloudflare/wrangler]
  • packages/vite-plugin-cloudflare/src/miniflare-options.ts: [@cloudflare/wrangler]
  • packages/workers-utils/src/config/containers.ts: [@cloudflare/wrangler]
  • packages/workers-utils/src/config/environment.ts: [@cloudflare/wrangler]
  • packages/workers-utils/src/config/exports.ts: [@cloudflare/wrangler]
  • packages/workers-utils/src/config/index.ts: [@cloudflare/wrangler]
  • packages/workers-utils/src/config/validation.ts: [@cloudflare/wrangler]
  • packages/workers-utils/src/index.ts: [@cloudflare/wrangler]
  • packages/workers-utils/src/types.ts: [@cloudflare/wrangler]
  • packages/workers-utils/src/worker.ts: [@cloudflare/wrangler]
  • packages/workers-utils/tests/config/containers.test.ts: [@cloudflare/wrangler]
  • packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/e2e/durable-objects-exports.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/cloudchamber/apply.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/containers/config.test.ts: [@cloudflare/cloudchamber @cloudflare/wrangler]
  • packages/wrangler/src/tests/containers/deploy.test.ts: [@cloudflare/cloudchamber @cloudflare/wrangler]
  • packages/wrangler/src/tests/containers/schema.test.ts: [@cloudflare/cloudchamber @cloudflare/wrangler]
  • packages/wrangler/src/tests/create-worker-upload-form/metadata.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/dev.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/helpers/mock-upload-worker.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/versions/versions.upload.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/api/integrations/platform/index.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/containers/config.ts: [@cloudflare/cloudchamber @cloudflare/wrangler]

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

✅ All changesets look good

@ask-bonk

ask-bonk Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Here rawConfig is the top-level raw config. rawConfig?.containers !== undefined — the parameter is named topLevelDeclaresContainers. For the top-level environment call, rawConfig is the top-level config, so this correctly reflects whether containers were declared at top level. For a named env call, rawConfig is still the top-level config (that's the guard semantics they want). This matches the documented behavior.

I've completed a thorough review. The changes are well-structured, the validation is comprehensive, edge cases (tombstones, inheritance, duplicate names, both-direction consistency) are handled, and test coverage is extensive. I found no logic bugs, security issues, backward-compat violations, or incorrect API behavior.

LGTM

github run

@pkg-pr-new

pkg-pr-new Bot commented Aug 4, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/@cloudflare/autoconfig@15026

@cloudflare/build-output-utils

npm i https://pkg.pr.new/@cloudflare/build-output-utils@15026

@cloudflare/config

npm i https://pkg.pr.new/@cloudflare/config@15026

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@15026

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@15026

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@15026

miniflare

npm i https://pkg.pr.new/miniflare@15026

@cloudflare/pages-functions

npm i https://pkg.pr.new/@cloudflare/pages-functions@15026

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@15026

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@15026

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@15026

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@15026

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@15026

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@15026

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@15026

wrangler

npm i https://pkg.pr.new/wrangler@15026

commit: 676980f

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

`versions upload` sends `containers[].name` alongside `class_name` now, so
this assertion needs the derived `<worker>-<class>` name. The container in
this test has no explicit `name`, which is exactly the case where
validation fills one in.
devin-ai-integration[bot]

This comment was marked as resolved.

Comment thread .changeset/config-export-container-field.md
Comment thread packages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts
The round-trip consistency check only looked one way: it compared a
container's `class_name` against the `container` field of that same class's
export. It never asked whether some *other* export had claimed the
container, so this contradictory config was accepted silently:

    "containers": [{ "name": "x", "class_name": "Y", "image": "..." }],
    "exports": {
      "Y": { "type": "durable-object", "storage": "sqlite" },
      "MyDO": { "type": "durable-object", "storage": "sqlite", "container": "x" }
    }

`exports.Y` names no container, so the existing comparison had nothing to
compare against. Wrangler would then resolve the container to `Y` locally
while the upload metadata let the API resolve it to `MyDO`.

When more than one export claims the container, the existing
duplicate-claim error already covers it, and singling one of them out here
would make the message depend on the order of the keys in `exports`.

Reported by Devin Review on #15026.
devin-ai-integration[bot]

This comment was marked as resolved.

Two containers sharing a `class_name` was accepted but could never work:
workerd attaches a single container per Durable Object namespace, and in
local dev `getDevContainerImageName` derives the image tag from the class
name alone, so both containers build into the same tag and one silently
overwrites the other. The vite plugin's map keyed by `image_tag` and
miniflare's `Map<className, { container? }>` each drop one too.

The cross-field validation added in this branch made that latent problem
incoherent rather than fixing it. The same config was accepted, rejected as
a link "disagreement", or rejected as a duplicate derived name depending on
incidental details:

  a, b both `class_name: MyDO`, no export names either  -> accepted
  a, b both `class_name: MyDO`, `exports.MyDO.container: a` -> "disagreement"
  two unnamed containers on MyDO -> duplicate name "worker-mydo"

All three now report the same root cause. The round-trip checks are skipped
for an over-subscribed class so they no longer blame a container for a
sibling owning the class's `container` field.

The claim that a Durable Object could be backed by several containers was
introduced earlier in this branch, in a doc comment and a test; both are
corrected. `cloudchamber/apply` had a two-application test that copied
`class_name` via a spread, so the second app now gets its own class.

Reported by Devin Review on #15026.
devin-ai-integration[bot]

This comment was marked as resolved.

`exports` is inherited by named environments but `containers` is not, so
the idiomatic multi-environment layout declares `exports` once at the top
level and repeats `containers` in each environment. The top level pass then
saw an `exports[Class].container` reference with no containers to match it
against and reported it as dangling.

Because that pass writes to the root diagnostics, the error surfaced even
when a named environment was selected, so the config could not be loaded
for any environment.

The existing guard only covered the mirror case, where a named environment
inherits `exports` without redeclaring `containers`. Generalise it to skip
the cross-check whenever the containers are declared at a different
environment level, in either direction.
The "skips containers" test asserted only that an all-unlinked container
list produces an empty array, which left it unclear whether the empty
array or `undefined` was intended, and did not actually prove that a
linked container survives alongside an unlinked one.

Assert the mixed case instead, and keep the empty result as a separate
test that says why both shapes are equivalent: they mean the same thing
and both call sites iterate `options ?? []`. Document the empty array on
`getContainerOptions`, whose contract previously mentioned only
`undefined`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Untriaged

Development

Successfully merging this pull request may close these issues.

3 participants